C++ 设计模式入门:设计原则与常用模式实现
设计模式是可复用面向对象软件的基础,但背UML图意义不大——每个模式都是在回答「什么在变化,如何隔离这个变化」。本文先给出六大设计原则与模式分类作为地图,然后逐个展开六个常用模式:观察者、装饰、工厂方法、抽象工厂、单例、职责链,每个都从动机讲起,配 C++ 代码与类图。适合有一定 C++ 基础、想系统理解设计模式动机的读者。
六大设计原则
原则是模式的底层依据,比模式本身更稳定:
- 单一职责原则:就一个类而言,应该仅有一个引起它变化的原因。
- 开放封闭原则:软件实体可以扩展,但是不可修改。面对新需求,通过增加代码完成改动,而不是修改现有代码。
- 里氏代换原则:使用基类的地方一定适用于其派生类——把基类替换成派生类,程序行为不应变化。
- 依赖倒转原则:抽象不应该依赖细节,细节应该依赖抽象。针对接口编程,不针对实现编程。
- 迪米特原则:两个类不直接通信,就不应发生直接的相互作用;需要调用时可通过第三个类转发。
- 接口隔离原则:接口中不应存在派生类用不到却必须实现的方法;否则应将接口拆分。
模式分类
经典的 GoF 23 个模式按目的分为三类:
| 类别 | 模式 |
|---|---|
| 创建型 | 单例、工厂方法、抽象工厂、建造者、原型 |
| 结构型 | 适配器、桥接、外观、组合、装饰、享元、代理 |
| 行为型 | 责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者 |
另一个有价值的视角是从封装变化的方向分类:组件协作(观察者、模板方法、策略)、单一职责(装饰、桥接)、对象创建(工厂方法、抽象工厂、原型)、对象性能(单例、享元)。同一个模式在不同视角下归入不同类别,说明分类只是理解工具,不是目的。
观察者模式
动机
软件中常存在「通知依赖关系」:一个对象(目标)状态改变,所有依赖对象(观察者)都要得到通知。如果目标直接依赖观察者的具体实现(比如进度通知直接依赖某个进度条控件类),这种紧密依赖使软件难以应对展现方式的变化。观察者模式用抽象的通知接口把这种依赖弱化为稳定关系,实现松耦合。
定义
定义对象间一种一对多的依赖关系,当一个对象(Subject)的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。——GoF
结构
例子:文件分割器的进度通知
把一个大文件分割为几个小文件,需要展示进度。直接做法是在分割器类中持有具体的通知控件(如 ProgressBar):
- 违背依赖倒置原则——分割器的核心逻辑依赖了进度条这一细节;
- 进度的展现方式可能变化:GUI 控件、控制台输出、日志,每换一种展现都要改分割器。
观察者模式的做法:
- 定义抽象通知接口
IProgress,分割器只依赖这个接口; - 观察者类继承
IProgress,在自己的DoProgress实现里更新具体控件; - 有多个观察者时,目标维护观察者列表,提供
add/remove方法。
要点
- 目标发送通知时无需指定观察者,通知(可携带信息作为参数)自动传播。
- 观察者自己决定是否订阅,目标对象对此一无所知,二者可以独立变化。
- 观察者模式是基于事件的 UI 框架中最常用的设计模式之一,也是 MVC 的重要组成部分。
装饰模式
动机
用继承扩展对象功能是静态的:扩展维度一多,各种功能组合会导致子类数量急剧膨胀。例如 IO 流有文件流、网络流、内存流三种主体,再各派生加密流、缓冲流,子类就是三乘二再加组合。装饰模式改用「组合 + 运行时装配」:主体类只管业务操作,扩展操作持有主体对象,在调用前后添加额外行为。
定义
动态(组合)地给一个对象增加一些额外的职责。就增加功能而言,装饰模式比生成子类(继承)更为灵活。——GoF
结构
装饰类在接口上是 is-a Component(继承同一接口),在实现上是 has-a Component(持有被装饰对象):
代码
// 业务操作
class Stream {
public:
virtual char Read(int number) = 0;
virtual void Seek(int position) = 0;
virtual void Write(char data) = 0;
virtual ~Stream() {}
};
// 主体类
class FileStream : public Stream {
public:
virtual char Read(int number) { /* 读文件流 */ }
virtual void Seek(int position){ /* 定位文件流 */ }
virtual void Write(char data) { /* 写文件流 */ }
};
class NetworkStream : public Stream {
public:
virtual char Read(int number) { /* 读网络流 */ }
virtual void Seek(int position){ /* 定位网络流 */ }
virtual void Write(char data) { /* 写网络流 */ }
};
class MemoryStream : public Stream {
public:
virtual char Read(int number) { /* 读内存流 */ }
virtual void Seek(int position){ /* 定位内存流 */ }
virtual void Write(char data) { /* 写内存流 */ }
};
// 扩展操作:共同持有的 Stream* 成员上提到中间类
class DecoratorStream : public Stream {
protected:
Stream* stream;
DecoratorStream(Stream* stm) : stream(stm) {}
};
class CryptoStream : public DecoratorStream {
public:
CryptoStream(Stream* stm) : DecoratorStream(stm) {}
virtual char Read(int number) {
// 额外的加密操作...
return stream->Read(number);
}
virtual void Seek(int position) {
stream->Seek(position);
// 额外的加密操作...
}
virtual void Write(char data) {
// 额外的加密操作...
stream->Write(data);
// 额外的加密操作...
}
};
class BufferedStream : public DecoratorStream {
public:
BufferedStream(Stream* stm) : DecoratorStream(stm) {}
// 同样在转发前后添加缓冲逻辑
};
void Process() {
// 运行时装配:主体与扩展自由组合
FileStream* s1 = new FileStream();
CryptoStream* s2 = new CryptoStream(s1); // 加密文件流
BufferedStream* s4 = new BufferedStream(s2); // 又加缓冲
} 要点
- 装饰类接口上继承 Component、实现上组合 Component,这是它比继承灵活的来源。
- 装饰模式解决的是「主体类在多个方向上的扩展功能」,而不是单纯减少子类数量。
- 三个主体类加两种扩展,继承需要 3×2 个子类且代码重复;装饰只需要 3 + 2 个类。
工厂方法模式
动机
new 具体类型是编译期绑定:要创建的具体类型一变,客户程序就得改。工厂方法把「实例化哪个类」延迟到工厂子类决定,隔离对象使用者与具体类型。
定义
定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到子类(目的:解耦,手段:虚函数)。——GoF
结构
例子:文件分割器
需求变化:要支持多种文件(二进制、文本、图片、视频)。直接在主界面 MainForm 里 new BinarySplitter(),主界面就依赖了具体类,违背依赖倒置原则。
工厂方法的做法:定义分割器工厂基类 SplitterFactory,声明 CreateSplitter 接口;每个具体分割器对应一个具体工厂。MainForm 构造时接收工厂基类指针,需要分割器时调用 CreateSplitter——主界面只认识两个抽象类,新增文件类型只需新增一对「具体分割器 + 具体工厂」。
要点
- 解决「单个对象」的创建变化,代价是每种具体类都要配套一个工厂类。
- 把选择判断移到客户端(配置、注入处),符合开放封闭原则。
- 要求各类的创建方法签名相同。
抽象工厂模式
动机
经常面临的不是单个对象的变化,而是「一系列相互依赖的对象」:比如数据库访问中的连接、命令、读取器三者必须同属一个系列(都是 SQL 或都是 Oracle)。用三个独立的工厂方法,系列一致性约束就散落在调用方。抽象工厂把一系列相关对象的创建合并到同一个工厂接口。
定义
提供一个接口,让该接口负责创建一系列相关或者相互依赖的对象,无需指定它们具体的类。——GoF
结构
要点
- 「系列对象」指某一特定系列内部相互依赖、相互配合的对象,不同系列之间不能混用。
- 抽象工厂应对「新系列」的变动很方便(新增一个工厂类);应对「新对象种类」的变动困难(所有工厂都要改)。
- 没有多系列需求时,不必上抽象工厂,简单工厂即可。
单例模式
动机
有些类(配置中心、日志系统、资源管理器)必须保证全局只有一个实例,且这个约束应由类设计者保证,而不是寄希望于使用者自律。
定义
保证一个类仅有一个实例,并提供一个访问它的全局访问点。——GoF
结构与实现
单例的写法演进本身就记录了多线程知识的发展:
class Singleton {
private:
Singleton();
Singleton(const Singleton& other);
public:
static Singleton* getInstance();
static Singleton* m_instance;
};
Singleton* Singleton::m_instance = nullptr;
// 版本一:线程非安全
Singleton* Singleton::getInstance() {
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
// 版本二:线程安全,但每次访问都加锁,代价过高
Singleton* Singleton::getInstance() {
Lock lock;
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
// 版本三:双检查锁(DCLP)——仍有隐患
Singleton* Singleton::getInstance() {
if (m_instance == nullptr) {
Lock lock;
if (m_instance == nullptr) {
m_instance = new Singleton();
}
}
return m_instance;
}
// 版本四:C++11 std::atomic 实现的 DCLP,跨平台正确
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
} 版本三的隐患在于编译器的内存读写重排:new Singleton() 实际是「分配内存、构造对象、赋值指针」三步,重排后指针可能先于构造完成被赋值,另一个线程随即拿到未构造完的对象。C++11 之前这一点无法跨平台修复(volatile 不是线程同步原语,不解决重排问题);C++11 之后既可以用 acquire/release 语义的原子操作修复 DCLP(版本四),也有一个更简单的选择——
static Singleton& getInstance() {
static Singleton instance; // C++11 起局部静态变量初始化线程安全
return instance;
} C++11 标准保证:多线程并发进入局部静态变量的初始化时,后到的线程会等待初始化完成。这就是 Meyers 单例,无锁、无重排问题,是现代 C++ 的首选写法。
要点
- 构造函数私有,拷贝构造与 Clone 接口一律删除——否则唯一实例约束形同虚设。
- 实例构造器可设为 protected 以允许子类派生。
- 多线程环境优先用局部静态变量(C++11 起)或原子操作 DCLP,不要裸用双检查锁,更不要指望
volatile。
职责链模式
动机
一个请求可能有多个候选处理者,但每个请求最终只有一个真正的接收者。若发送者显式指定接收者,二者紧耦合,接收者变化会波及发送者。职责链让多个对象串成链,请求沿链传递,直到有一个对象处理它为止。
定义
使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。——GoF
结构
要点
- 适用于「多个候选接收者、最终只有一个处理」的场景,发送者与接收者解耦。
- 各处理类只认识自己的后继,链的结构可以在运行时组装。
- 在现代框架里,这一模式常被事件系统、中间件机制以其他形式实现,独立的职责链类结构已较少直接使用。
结语
六个模式对应六种变化:观察者隔离「通知关系」的变化,装饰隔离「功能扩展方向」的变化,工厂方法与抽象工厂隔离「创建类型」的变化,单例约束「实例数量」,职责链隔离「请求接收者」的变化。遇到模式先问「它在封装哪个变化」,比记住结构图更有用;六大原则(尤其是依赖倒置与开放封闭)是所有模式共同的底层逻辑。